import MarkupSection, {
  DEFAULT_TAG_NAME,
  VALID_MARKUP_SECTION_TAGNAMES,
  isMarkupSection as sectionIsMarkupSection,
} from '../models/markup-section'
import { VALID_LIST_SECTION_TAGNAMES, isListSection as sectionIsListSection } from '../models/list-section'
import { VALID_LIST_ITEM_TAGNAMES, isListItem as sectionIsListItem } from '../models/list-item'
import { LIST_SECTION_TYPE, LIST_ITEM_TYPE, MARKUP_SECTION_TYPE } from '../models/types'
import Markup, { VALID_MARKUP_TAGNAMES } from '../models/markup'
import {
  getAttributes,
  normalizeTagName,
  isTextNode,
  isCommentNode,
  NODE_TYPES,
  isElementNode,
} from '../utils/dom-utils'
import { any, forEach, contains } from '../utils/array-utils'
import { transformHTMLText, trimSectionText } from '../parsers/dom'
import assert, { assertType, expect } from '../utils/assert'
import PostNodeBuilder from '../models/post-node-builder'
import Section from '../models/_section'
import Marker from '../models/marker'
import Markerable, { isMarkerable } from '../models/_markerable'
import { Cloneable } from '../models/_cloneable'

const SKIPPABLE_ELEMENT_TAG_NAMES = ['style', 'head', 'title', 'meta'].map(normalizeTagName)

const NEWLINES = /\s*\n\s*/g
function sanitize(text: string) {
  return text.replace(NEWLINES, ' ')
}

/**
 * parses an element into a section, ignoring any non-markup
 * elements contained within
 * @private
 */

interface SectionParserOptions {
  plugins?: SectionParserPlugin[]
}

interface SectionParserState {
  section?: Cloneable<Section> | null
  text?: string
  markups?: Markup[]
}

interface SectionParseEnv {
  addSection: (section: Cloneable<Section>) => void
  addMarkerable: (marker: Marker) => void
  nodeFinished(): void
}

export type SectionParserPlugin = (node: Node, builder: PostNodeBuilder, env: SectionParseEnv) => void

type SectionParserNode = HTMLElement | Text | Comment

export default class SectionParser {
  builder: PostNodeBuilder
  plugins: SectionParserPlugin[]

  sections!: Cloneable<Section>[]
  state!: SectionParserState

  constructor(builder: PostNodeBuilder, options: SectionParserOptions = {}) {
    this.builder = builder
    this.plugins = options.plugins || []
  }

  parse(element: HTMLElement) {
    if (this._isSkippable(element)) {
      return []
    }
    this.sections = []
    this.state = {}

    this._updateStateFromElement(element)

    let finished = false

    // top-level text nodes will be run through parseNode later so avoid running
    // the node through parserPlugins twice
    if (!isTextNode(element)) {
      finished = this.runPlugins(element)
    }

    if (!finished) {
      let childNodes = isTextNode(element) ? [element] : element.childNodes

      forEach(childNodes, el => {
        this.parseNode(el as SectionParserNode)
      })
    }

    this._closeCurrentSection()

    return this.sections
  }

  runPlugins(node: Node) {
    let isNodeFinished = false
    let env = {
      addSection: (section: Cloneable<Section>) => {
        // avoid creating empty paragraphs due to wrapper elements around
        // parser-plugin-handled elements
        if (this.state.section && isMarkerable(this.state.section) && !this.state.section.text && !this.state.text) {
          this.state.section = null
        } else {
          this._closeCurrentSection()
        }
        this.sections.push(section)
      },
      addMarkerable: (marker: Marker) => {
        let { state } = this
        let { section } = state
        // if the first element doesn't create it's own state and it's plugin
        // handler uses `addMarkerable` we won't have a section yet
        if (!section) {
          state.text = ''
          state.section = this.builder.createMarkupSection(normalizeTagName('p'))
          section = state.section
        }
        assertType<Markerable>(
          'Markerables can only be appended to markup sections and list item sections',
          section,
          section && section.isMarkerable
        )
        if (state.text) {
          this._createMarker()
        }
        section.markers.append(marker)
      },
      nodeFinished() {
        isNodeFinished = true
      },
    }
    for (let i = 0; i < this.plugins.length; i++) {
      let plugin = this.plugins[i]
      plugin(node, this.builder, env)
      if (isNodeFinished) {
        return true
      }
    }
    return false
  }

  /* eslint-disable complexity */
  parseNode(node: SectionParserNode) {
    if (!this.state.section) {
      this._updateStateFromElement(node)
    }

    let nodeFinished = this.runPlugins(node)
    if (nodeFinished) {
      return
    }

    // handle closing the current section and starting a new one if we hit a
    // new-section-creating element.
    if (this.state.section && isElementNode(node) && node.tagName) {
      let tagName = normalizeTagName(node.tagName)
      let isListSection = contains(VALID_LIST_SECTION_TAGNAMES, tagName)
      let isListItem = contains(VALID_LIST_ITEM_TAGNAMES, tagName)
      let isMarkupSection = contains(VALID_MARKUP_SECTION_TAGNAMES, tagName)
      let isNestedListSection = isListSection && this.state.section.isListItem
      let lastSection = this.sections[this.sections.length - 1]

      // lists can continue after breaking out for a markup section,
      // in that situation, start a new list using the same list type
      if (isListItem && sectionIsMarkupSection(this.state.section)) {
        this._closeCurrentSection()
        this._updateStateFromElement(node.parentElement!)
      }

      // we can hit a list item after parsing a nested list, when that happens
      // and the lists are of different types we need to make sure we switch
      // the list type back
      if (isListItem && lastSection && sectionIsListSection(lastSection)) {
        let parentElement = expect(node.parentElement, 'expected node to have parent element')
        let parentElementTagName = normalizeTagName(parentElement.tagName)
        if (parentElementTagName !== lastSection.tagName) {
          this._closeCurrentSection()
          this._updateStateFromElement(parentElement)
        }
      }

      // if we've broken out of a list due to nested section-level elements we
      // can hit the next list item without having a list section in the current
      // state. In this instance we find the parent list node and use it to
      // re-initialize the state with a new list section
      if (
        isListItem &&
        !(this.state.section.isListItem || this.state.section.isListSection) &&
        !lastSection.isListSection
      ) {
        this._closeCurrentSection()
        this._updateStateFromElement(node.parentElement!)
      }

      // if we have consecutive list sections of different types (ul, ol) then
      // ensure we close the current section and start a new one
      let isNewListSection =
        lastSection &&
        sectionIsListSection(lastSection) &&
        this.state.section.isListItem &&
        isListSection &&
        tagName !== lastSection.tagName

      if (isNewListSection || (isListSection && !isNestedListSection) || isMarkupSection || isListItem) {
        // don't break out of the list for list items that contain a single <p>.
        // deals with typical case of <li><p>Text</p></li><li><p>Text</p></li>
        if (
          this.state.section.isListItem &&
          tagName === 'p' &&
          !node.nextSibling &&
          contains(
            VALID_LIST_ITEM_TAGNAMES,
            normalizeTagName(expect(node.parentElement, 'expected node to have parent element').tagName)
          )
        ) {
          this.parseElementNode(node)
          return
        }

        // avoid creating empty paragraphs due to wrapper elements around
        // section-creating elements
        if (isMarkerable(this.state.section) && !this.state.text && this.state.section.markers.length === 0) {
          this.state.section = null
        } else {
          this._closeCurrentSection()
        }

        this._updateStateFromElement(node)
      }

      if (this.state.section && this.state.section.isListSection) {
        // ensure the list section is closed and added to the sections list.
        // _closeCurrentSection handles pushing list items onto the list section
        this._closeCurrentSection()

        forEach(node.childNodes, node => {
          this.parseNode(node as SectionParserNode)
        })
        return
      }
    }

    switch (node.nodeType) {
      case NODE_TYPES.TEXT:
        this.parseTextNode(node as Text)
        break
      case NODE_TYPES.ELEMENT:
        this.parseElementNode(node as HTMLElement)
        break
    }
  }

  parseElementNode(element: HTMLElement) {
    let { state } = this
    assert('expected markups to be non-null', state.markups)

    const markups = this._markupsFromElement(element)
    if (markups.length && state.text!.length && isMarkerable(state.section!)) {
      this._createMarker()
    }
    state.markups.push(...markups)

    forEach(element.childNodes, node => {
      this.parseNode(node as SectionParserNode)
    })

    if (markups.length && state.text!.length && state.section!.isMarkerable) {
      // create the marker started for this node
      this._createMarker()
    }

    // pop the current markups from the stack
    state.markups.splice(-markups.length, markups.length)
  }

  parseTextNode(textNode: Text) {
    let { state } = this
    state.text += sanitize(textNode.textContent!)
  }

  _updateStateFromElement(element: SectionParserNode) {
    if (isCommentNode(element)) {
      return
    }

    let { state } = this
    state.section = this._createSectionFromElement(element)
    state.markups = this._markupsFromElement(element)
    state.text = ''
  }

  _closeCurrentSection() {
    let { sections, state } = this
    let lastSection = sections[sections.length - 1]

    if (!state.section) {
      return
    }

    // close a trailing text node if it exists
    if (state.text!.length && state.section.isMarkerable) {
      this._createMarker()
    }

    // push listItems onto the listSection or add a new section
    if (sectionIsListItem(state.section) && lastSection && sectionIsListSection(lastSection)) {
      trimSectionText(state.section)
      lastSection.items.append(state.section)
    } else {
      // avoid creating empty markup sections, especially useful for indented source
      if (
        isMarkerable(state.section) &&
        !state.section.text.trim() &&
        !any(state.section.markers, marker => marker.isAtom)
      ) {
        state.section = null
        state.text = ''
        return
      }

      // remove empty list sections before creating a new section
      if (lastSection && sectionIsListSection(lastSection) && lastSection.items.length === 0) {
        sections.pop()
      }

      sections.push(state.section)
    }

    state.section = null
    state.text = ''
  }

  _markupsFromElement(element: HTMLElement | Text) {
    let { builder } = this
    let markups: Markup[] = []
    if (isTextNode(element)) {
      return markups
    }

    const tagName = normalizeTagName(element.tagName)
    if (this._isValidMarkupForElement(tagName, element)) {
      markups.push(builder.createMarkup(tagName, getAttributes(element)))
    }

    this._markupsFromElementStyle(element).forEach(markup => markups.push(markup))

    return markups
  }

  _isValidMarkupForElement(tagName: string, element: HTMLElement) {
    if (VALID_MARKUP_TAGNAMES.indexOf(tagName) === -1) {
      return false
    } else if (tagName === 'b') {
      // google docs add a <b style="font-weight: normal;"> that should not
      // create a "b" markup
      return element.style.fontWeight !== 'normal'
    }
    return true
  }

  _markupsFromElementStyle(element: HTMLElement) {
    let { builder } = this
    let markups: Markup[] = []
    let { fontStyle, fontWeight } = element.style
    if (fontStyle === 'italic') {
      markups.push(builder.createMarkup('em'))
    }
    if (fontWeight === 'bold' || fontWeight === '700') {
      markups.push(builder.createMarkup('strong'))
    }
    return markups
  }

  _createMarker() {
    let { state } = this
    let text = transformHTMLText(state.text!)
    let marker = this.builder.createMarker(text, state.markups)
    assertType<Markerable>('expected section to be markerable', state.section, isMarkerable(state.section!))
    state.section.markers.append(marker)
    state.text = ''
  }

  _getSectionDetails(element: HTMLElement | Text) {
    let sectionType: string,
      tagName: string,
      inferredTagName = false

    if (isTextNode(element)) {
      tagName = DEFAULT_TAG_NAME
      sectionType = MARKUP_SECTION_TYPE
      inferredTagName = true
    } else {
      tagName = normalizeTagName(element.tagName)

      // blockquote>p is valid html and should be treated as a blockquote section
      // rather than a plain markup section
      if (
        tagName === 'p' &&
        element.parentElement &&
        normalizeTagName(element.parentElement.tagName) === 'blockquote'
      ) {
        tagName = 'blockquote'
      }

      if (contains(VALID_LIST_SECTION_TAGNAMES, tagName)) {
        sectionType = LIST_SECTION_TYPE
      } else if (contains(VALID_LIST_ITEM_TAGNAMES, tagName)) {
        sectionType = LIST_ITEM_TYPE
      } else if (contains(VALID_MARKUP_SECTION_TAGNAMES, tagName)) {
        sectionType = MARKUP_SECTION_TYPE
      } else {
        sectionType = MARKUP_SECTION_TYPE
        tagName = DEFAULT_TAG_NAME
        inferredTagName = true
      }
    }

    return { sectionType, tagName, inferredTagName }
  }

  _createSectionFromElement(element: Comment | HTMLElement) {
    if (isCommentNode(element)) {
      return
    }

    let { builder } = this
    let section: Cloneable<Section>
    let { tagName, sectionType, inferredTagName } = this._getSectionDetails(element)

    switch (sectionType) {
      case LIST_SECTION_TYPE:
        section = builder.createListSection(tagName)
        break
      case LIST_ITEM_TYPE:
        section = builder.createListItem()
        break
      case MARKUP_SECTION_TYPE:
        section = builder.createMarkupSection(tagName)
        ;(section as MarkupSection)._inferredTagName = inferredTagName
        break
      default:
        assert('Cannot parse section from element', false)
    }

    return section
  }

  _isSkippable(element: Node) {
    return isElementNode(element) && contains(SKIPPABLE_ELEMENT_TAG_NAMES, normalizeTagName(element.tagName))
  }
}
